None 01_EDA
In [1]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import plotly.io as pio
pio.renderers.default = "notebook_connected"
from pathlib import Path
import seaborn as sns
from PIL import Image
from scipy.spatial.distance import pdist
import os
import cv2
import glob
from tqdm import tqdm
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from tqdm import tqdm
import warnings
warnings.filterwarnings("ignore")

Chargement des données et Inventaire

Structure hiérarchique :

  • Patch : L'unité de base (une petite zone de tissu).
  • Patient (43 uniques) : Un patient a plusieurs ganglions.
  • Node (Ganglion) : Un patient peut avoir plusieurs ganglions analysés.
  • Centre (5 hôpitaux) : Chaque patient est lié à un seul hôpital.
In [2]:
# Chemins
ROOT_DIR = Path('../data/raw/wilds/camelyon17_v1.0')
PATCHES_DIR = ROOT_DIR / 'patches'
METADATA_PATH = ROOT_DIR / 'metadata.csv'

df = pd.read_csv(METADATA_PATH, index_col=0)
df.head()
Out[2]:
patient node x_coord y_coord tumor slide center split
0 4 4 3328 21792 1 0 0 0
1 4 4 3200 22272 1 0 0 0
2 4 4 3168 22272 1 0 0 0
3 4 4 3328 21760 1 0 0 0
4 4 4 3232 22240 1 0 0 0
In [3]:
df.info()
<class 'pandas.core.frame.DataFrame'>
Index: 455954 entries, 0 to 455953
Data columns (total 8 columns):
 #   Column   Non-Null Count   Dtype
---  ------   --------------   -----
 0   patient  455954 non-null  int64
 1   node     455954 non-null  int64
 2   x_coord  455954 non-null  int64
 3   y_coord  455954 non-null  int64
 4   tumor    455954 non-null  int64
 5   slide    455954 non-null  int64
 6   center   455954 non-null  int64
 7   split    455954 non-null  int64
dtypes: int64(8)
memory usage: 31.3 MB
In [4]:
df.describe()
Out[4]:
patient node x_coord y_coord tumor slide center split
count 455954.000000 455954.000000 455954.000000 455954.000000 455954.000000 455954.000000 455954.000000 455954.000000
mean 62.137053 1.759796 16371.002899 20426.509832 0.500000 31.668749 2.591082 0.099999
std 27.795119 1.462122 9960.497762 12735.245587 0.500001 14.348881 1.349268 0.299999
min 4.000000 0.000000 160.000000 160.000000 0.000000 0.000000 0.000000 0.000000
25% 44.000000 1.000000 7904.000000 9472.000000 0.000000 23.000000 2.000000 0.000000
50% 72.000000 1.000000 15616.000000 18048.000000 0.500000 37.000000 3.000000 0.000000
75% 86.000000 3.000000 22624.000000 30912.000000 1.000000 43.000000 4.000000 0.000000
max 99.000000 4.000000 43776.000000 52160.000000 1.000000 49.000000 4.000000 1.000000
In [5]:
print(f"Nombre total de patchs: {len(df):,}")
print(f"Nombre de patients: {df['patient'].nunique()}")
print(f"Nombre d'hôpitaux: {df['center'].nunique()}")
Nombre total de patchs: 455,954
Nombre de patients: 43
Nombre d'hôpitaux: 5
In [6]:
# Création des colonnes
df['Label'] = df['tumor'].map({0: 'Normal', 1: 'Tumeur'})
df['Center_Name'] = 'Hôpital ' + df['center'].astype(str)
df['Split_Name'] = df['split'].map({0: 'Train/Val (ID)', 1: 'Test (OOD)'})

df.head()
Out[6]:
patient node x_coord y_coord tumor slide center split Label Center_Name Split_Name
0 4 4 3328 21792 1 0 0 0 Tumeur Hôpital 0 Train/Val (ID)
1 4 4 3200 22272 1 0 0 0 Tumeur Hôpital 0 Train/Val (ID)
2 4 4 3168 22272 1 0 0 0 Tumeur Hôpital 0 Train/Val (ID)
3 4 4 3328 21760 1 0 0 0 Tumeur Hôpital 0 Train/Val (ID)
4 4 4 3232 22240 1 0 0 0 Tumeur Hôpital 0 Train/Val (ID)
In [7]:
#ratio tumeur/non tumeur
tumeur = df['tumor'].value_counts()[1]
nontumeur = df['tumor'].value_counts()[0]
fig_tumeur = px.pie(df, names='tumor', title='Ratio Tumeur/Non Tumeur')
fig_tumeur.show()
In [8]:
# Distribution des Splits
split_counts = df['Split_Name'].value_counts().reset_index()
split_counts.columns = ['Split', 'Nombre']
fig_split = px.pie(split_counts, values='Nombre', names='Split', 
                   title="Répartition des Splits WILDS",
                   color_discrete_sequence=px.colors.qualitative.Pastel)
fig_split.show()

Analyse Patients

On est passé d'une classification à 4 stades (pN0-3) à une classification binaire (Atteint / Non atteint) au niveau du patient.

  • **Infos Patients***
In [9]:
    num_patients = df['patient'].nunique()
    print(f"Nombre total de patients uniques: {num_patients}")
Nombre total de patients uniques: 43
In [10]:
# Group by patient
patient_stats = df.groupby('patient').agg({
        'tumor': ['count', 'sum', 'mean'],
        'node': 'nunique',
        'center': 'nunique'
})
patient_stats.columns = ['n_patches', 'n_tumor', 'tumor_fraction', 'n_nodes', 'n_centers']
In [11]:
#stats Descriptive 
desc = patient_stats['n_patches'].describe()
print(f"Patchs par patient:")
print(f"Min: {desc['min']:.0f}")
print(f"Max: {desc['max']:.0f}")
print(f"Moyenne: {desc['mean']:.1f}")
print(f"Médiane: {desc['50%']:.1f}")
Patchs par patient:
Min: 1275
Max: 61110
Moyenne: 10603.6
Médiane: 7210.0
In [12]:
# vérifier si un patient n'appartient pas à plusieurs centre
multi_center_patients = patient_stats[patient_stats['n_centers'] > 1]
if len(multi_center_patients) > 0:
    print(f"ATTENTION: {len(multi_center_patients)} patients associés à plusieurs hôpitaux!")
else:
    print(f"Intégrité Hôpital: Chaque patient est unique à un seul hôpital.")
Intégrité Hôpital: Chaque patient est unique à un seul hôpital.
In [13]:
# Distribution des Patchs Tumoraux par Patient
print("Charge Tumorale par Patient")
    
# Patients avec 0 patchs tumoraux
n_healthy_patients = len(patient_stats[patient_stats['n_tumor'] == 0])
pct_healthy = (n_healthy_patients / num_patients) * 100
print(f"Patients sains (0 patchs tumoraux): {n_healthy_patients} ({pct_healthy:.1f}%)")
    
# Patients avec patchs tumoraux
n_sick_patients = num_patients - n_healthy_patients
pct_sick = (n_sick_patients / num_patients) * 100
print(f"Patients avec tumeur: {n_sick_patients} ({pct_sick:.1f}%)")
    
# stats des fractions de tumeur pour le patients malade
sick_df = patient_stats[patient_stats['n_tumor'] > 0]
if not sick_df.empty:
    tf_desc = sick_df['tumor_fraction'].describe()
    print(f"- Fraction tumorale (chez patients malades):")
    print(f"  - Min: {tf_desc['min']:.4f} ({tf_desc['min']*100:.2f}%)")
    print(f"  - Max: {tf_desc['max']:.4f} ({tf_desc['max']*100:.2f}%)")
    print(f"  - Moyenne: {tf_desc['mean']:.4f} ({tf_desc['mean']*100:.2f}%)")
    
Charge Tumorale par Patient
Patients sains (0 patchs tumoraux): 0 (0.0%)
Patients avec tumeur: 43 (100.0%)
- Fraction tumorale (chez patients malades):
  - Min: 0.0011 (0.11%)
  - Max: 0.9276 (92.76%)
  - Moyenne: 0.2731 (27.31%)
In [14]:
# 3. Distribution des  Nodes
print("Analyse des Nodes (Ganglions)")
node_desc = patient_stats['n_nodes'].describe()
print(f"- Nodes par patient:")
print(f"- Moyenne: {node_desc['mean']:.1f}")
print(f"- Max: {node_desc['max']:.0f}")
Analyse des Nodes (Ganglions)
- Nodes par patient:
- Moyenne: 1.2
- Max: 3

Dans une prédiction binaire, le danger n'est plus de se tromper entre le stade 2 et le stade 3, mais de passer à côté d'un patient "Hautement Difficile".

  • Les Patients "Aiguilles" : Certains patients n'ont que 0,11% de patchs tumoraux . Pour le modèle, c'est comme chercher une aiguille dans une botte de foin. Si elle rate ces quelques patchs, elle dira "Patient Sain" (Faux Négatif), ce qui est une erreur médicale grave.
  • Seuil de Détection: Analyse à faire : Étudier spécifiquement ces patients à très faible charge tumorale. Combien de patchs tumoraux ont-ils exactement ? 5 ? 10 ? C'est notre limite de détection
In [15]:
# Analyse de la charge tumorale par patient
patient_stats = df.groupby('patient')['tumor'].agg(
    total_patches='count',
    tumor_patches='sum'
).reset_index()

patient_stats['tumor_percentage'] = (patient_stats['tumor_patches'] / patient_stats['total_patches']) * 100

# Identification des patients "Aiguilles" (charge tumorale très faible mais non nulle)
low_load_patients = patient_stats[patient_stats['tumor_patches'] > 0].sort_values(by='tumor_percentage')

print("Patients avec la plus faible charge tumorale (Limite de détection) :")
display(low_load_patients.head(10))

# Analyse spécifique des patients proches du seuil de 0.11%
critical_threshold = 0.002  # 0.2%
critical_patients = low_load_patients[low_load_patients['tumor_percentage'] < critical_threshold * 100]

print(f"\nNombre de patients sous le seuil de {critical_threshold*100}% : {len(critical_patients)}")
print(f"Nombre de patchs tumoraux pour ces patients : {critical_patients['tumor_patches'].values}")
print(f"Minimum de patchs tumoraux à détecter pour ne pas rater un patient : {critical_patients['tumor_patches'].min()}")
Patients avec la plus faible charge tumorale (Limite de détection) :
patient total_patches tumor_patches tumor_percentage
36 86 15806 18 0.113881
24 60 10623 15 0.141203
5 16 3427 5 0.145900
30 68 10661 16 0.150080
16 41 3694 10 0.270709
37 87 7958 24 0.301583
27 64 4019 19 0.472754
15 40 3810 27 0.708661
0 4 4316 36 0.834106
35 81 11526 108 0.937012
Nombre de patients sous le seuil de 0.2% : 4
Nombre de patchs tumoraux pour ces patients : [18 15  5 16]
Minimum de patchs tumoraux à détecter pour ne pas rater un patient : 5

Analyse par Hôpital

  • Domain Shift Le Domain Shift est un défi majeur dans ce dataset : chaque hôpital possède ses propres scanners et protocoles de coloration.
In [16]:
# Distribution par hôpital et équilibre des classes
hosp_counts = df.groupby(['Center_Name', 'Label']).size().reset_index(name='Nbr Patchs')

fig = px.bar(hosp_counts, x="Center_Name", y="Nbr Patchs", color="Label", 
             title="Équilibre des Classes par Hôpital",
             barmode="group",
             color_discrete_map={'Normal': '#2ca02c', 'Tumeur': '#d62728'})
fig.show()
  • Quantification du Domain Shift (Statistiques RGB)

Nous échantillonnons 5000 patchs par hôpital pour calculer les statistiques de couleur.

In [17]:
def get_hosp_rgb_stats(n_samples=5000):
    centers = sorted(df['center'].unique())
    hosp_stats = []
    
    for center in tqdm(centers, desc="Analyse RGB par hôpital"):
        sample = df[df['center'] == center].sample(n_samples, random_state=42)
        r_vals, g_vals, b_vals = [], [], []
        
        for _, row in sample.iterrows():
            fname = f"patch_patient_{row['patient']:03d}_node_{row['node']}_x_{row['x_coord']}_y_{row['y_coord']}.png"
            path = PATCHES_DIR / f"patient_{row['patient']:03d}_node_{row['node']}" / fname
            if path.exists():
                img = np.array(Image.open(path).convert('RGB'))
                r_vals.append(img[:,:,0].mean())
                g_vals.append(img[:,:,1].mean())
                b_vals.append(img[:,:,2].mean())
        
        hosp_stats.append({
            'Hôpital': f'Hosp_{center}',
            'Red': np.mean(r_vals),
            'Green': np.mean(g_vals),
            'Blue': np.mean(b_vals),
            'Luminosité': 0.299*np.mean(r_vals) + 0.587*np.mean(g_vals) + 0.114*np.mean(b_vals)
        })
    return pd.DataFrame(hosp_stats)

rgb_df = get_hosp_rgb_stats()
fig_shift = px.line(rgb_df, x='Hôpital', y=['Red', 'Green', 'Blue', 'Luminosité'], 
                    title="Variation de la Colorimétrie par Hôpital (Domain Shift)",
                    markers=True,
                    labels={'value': 'Intensité Moyenne (0-255)', 'variable': 'Canal'},
                    color_discrete_map={'Green': 'green', 'Red': 'red', 'Blue':'blue', 'Luminosité':'purple'})
fig_shift.show()
Analyse RGB par hôpital: 100%|██████████| 5/5 [01:00<00:00, 12.17s/it]

1. Luminosité

  • Valeur élevée : Les images sont claires ou tirent vers le blanc. Cela survient généralement avec une exposition forte du scanner ou des tissus fins (ex: Hôpital 4).
  • Valeur basse : Les images sont sombres. Cela traduit souvent une coloration dense ou un réglage de capture plus sombre (ex: Hôpital 1).

2. Équilibre des canaux (RGB)

  • Canal Rouge (Red) : Représente principalement l'Éosine (coloration rose/rouge du cytoplasme et des protéines). Un niveau élevé indique des lames très "roses".
  • Canal Bleu (Blue) : Représente l'Hématoxyline (coloration bleu/violet des noyaux). Si ce canal domine, le rendu visuel est plus "froid" ou violacé.
  • Canal Vert (Green) : Moins présent dans les structures tissulaires, il sert d'indicateur de la pureté du fond. Plus sa valeur est haute, plus le fond blanc est propre et neutre.
  • Focus Robustesse et Biais
In [18]:
#DÉFINITIONS ET FONCTIONS ---

def generer_chemin_complet(ligne_metadata):
    """
    Reconstruit le chemin d'accès physique d'un patch sur le disque.
    Format : data/raw/wilds/camelyon17_v1.0/patches/patient_XXX_node_X/patch_...png
    """
    nom_fichier = f"patch_patient_{ligne_metadata['patient']:03d}_node_{ligne_metadata['node']}_x_{ligne_metadata['x_coord']}_y_{ligne_metadata['y_coord']}.png"
    dossier_parent = f"patient_{ligne_metadata['patient']:03d}_node_{ligne_metadata['node']}"
    return str(PATCHES_DIR / dossier_parent / nom_fichier)

def extraire_caracteristiques_visuelles(chemin_image):
    """
    Extrait 8 signatures numériques d'une image :
    - Moyennes RGB (3) : Couleur dominante.
    - Écarts-types RGB (3) : Variabilité de la couleur.
    - Contraste (1) : Différence ombre/lumière.
    - Variance Laplacienne (1) : Netteté (plus c'est bas, plus c'est flou).
    """
    image = cv2.imread(chemin_image)
    if image is None:
        return [np.nan] * 8
    
    # Conversion pour les calculs
    image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    image_gris = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    
    # Statistiques de Couleur (Moyenne et Écart-type)
    moyennes_rgb = np.mean(image_rgb, axis=(0, 1))
    ecarts_types_rgb = np.std(image_rgb, axis=(0, 1))
    
    # Texture et Netteté
    contraste = image_gris.std()
    score_nettete = cv2.Laplacian(image_gris, cv2.CV_64F).var()
    
    return list(moyennes_rgb) + list(ecarts_types_rgb) + [contraste, score_nettete]
In [19]:
# PRÉPARATION DES DONNÉES ---

# On génère les chemins pour tout le dataset
print("Génération des chemins d'accès...")
df['path'] = df.apply(generer_chemin_complet, axis=1)

# On sélectionne un échantillon représentatif pour l'analyse (5000 images)
# Note : Analyser les 455k images prendrait plusieurs heures.
NB_ECHANTILLONS = 5000
df_analyse = df.sample(NB_ECHANTILLONS, random_state=42).copy()
Génération des chemins d'accès...
In [20]:
#EXTRACTION DES FEATURES 

print(f"Extraction des caractéristiques sur {NB_ECHANTILLONS} patchs...")
noms_colonnes_stats = ['rouge_moy', 'vert_moy', 'bleu_moy', 'rouge_std', 'vert_std', 'bleu_std', 'contraste', 'score_nettete']
tqdm.pandas(desc="Analyse des images")

# Application de la fonction d'extraction
df_analyse[noms_colonnes_stats] = df_analyse['path'].progress_apply(
    lambda x: pd.Series(extraire_caracteristiques_visuelles(x))
)

# Nettoyage des éventuelles erreurs de lecture
df_analyse = df_analyse.dropna(subset=noms_colonnes_stats)
Extraction des caractéristiques sur 5000 patchs...
Analyse des images: 100%|██████████| 5000/5000 [00:24<00:00, 201.98it/s]
In [21]:
# ANALYSE PCA (SIGNATURE DE CENTRE) ---

print("Calcul de la PCA (Réduction de dimension)...")

# Normalisation des données (moyenne=0, variance=1) pour que le contraste ne domine pas la couleur
donnees_normalisees = StandardScaler().fit_transform(df_analyse[noms_colonnes_stats])

# Calcul de la PCA sur 2 composantes pour affichage 2D
pca_processeur = PCA(n_components=2)
resultats_pca = pca_processeur.fit_transform(donnees_normalisees)

# Ajout des coordonnées PCA au DataFrame pour la visualisation
df_analyse['pca_dimension_1'] = resultats_pca[:, 0]
df_analyse['pca_dimension_2'] = resultats_pca[:, 1]
Calcul de la PCA (Réduction de dimension)...
In [22]:
#VISUALISATION INTERACTIVE
fig = px.scatter(
    df_analyse, 
    x='pca_dimension_1', 
    y='pca_dimension_2', 
    color='Center_Name',
    title="Analyse PCA : Signature Visuelle par Hôpital (Domain Shift)",
    labels={
        'pca_dimension_1': 'Intensité et Luminosité Globale (PC1)', 
        'pca_dimension_2': 'Texture et Balance des Couleurs (PC2)'
    },
    hover_data=['patient', 'Label'],
    opacity=0.7,
    template="plotly_white"
)
# Amélioration du design du graphique
fig.update_traces(marker=dict(size=8))
fig.show()
  • Domain Shift : Différence de style visuel (colorimétrie, netteté, contraste) entre les différentes sources de données (centres hospitaliers).
  • PCA (Principal Component Analysis / Analyse en Composantes Principales) : Technique de réduction de dimensionnalité qui résume ici 8 caractéristiques complexes en 2 axes principaux pour visualiser et identifier les groupements de données.

Analyse du Graphique

  • Regroupement par couleur : Si les points d'une même couleur sont agglomérés, l'hôpital possède une "signature visuelle" spécifique.
  • Éloignement des clusters : Un écart important entre les groupes de couleurs indique un Domain Shift marqué. Cela signifie que le modèle pourrait corréler par erreur le style d'image d'un hôpital à une pathologie si aucune technique d'augmentation de données n'est appliquée.
  • Points isolés (Outliers) : Représentent des anomalies potentielles, telles que des patchs trop sombres, flous ou présentant des artefacts de numérisation.

Analyse de Qualité & Flou

Un échantillon de 5000 patchs a été analysé pour détecter le flou (variance du Laplacien) et les fonds blancs vides.

In [23]:
#  DÉFINITION DES CRITÈRES DE QUALITÉ ---

def analyser_metriques_qualite(chemin_image):
    """
    Calcule les indicateurs techniques d'une image :
    - Netteté : via la variance du Laplacien.
    - Luminosité moyenne : pour détecter le fond blanc.
    - Écart-type : pour détecter le manque de texture (zones vides).
    """
    image = cv2.imread(chemin_image)
    if image is None:
        return {'score_nettete': np.nan, 'intensite_moyenne': np.nan, 'stabilite_texture': np.nan}
    
    # Passage en gris pour les calculs de texture/netteté
    image_gris = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    
    # Score de netteté (Variance du Laplacien)
    # Plus ce score est élevé, plus l'image contient des détails fins (bords nets).
    score_nettete = cv2.Laplacian(image_gris, cv2.CV_64F).var()
    
    # Statistiques d'intensité
    intensite_moyenne = np.mean(image_gris)
    # Un écart-type faible signifie que tous les pixels se ressemblent (image plate)
    stabilite_texture = np.std(image_gris)
    
    return {
        'score_nettete': score_nettete, 
        'intensite_moyenne': intensite_moyenne, 
        'stabilite_texture': stabilite_texture
    }
In [24]:
#EXÉCUTION DE L'ANALYSE 

NB_PATCHS_QUALITE = 5000
print(f"Analyse de la qualité sur un échantillon de {NB_PATCHS_QUALITE} patchs...")

# Échantillonnage
df_qualite = df.sample(NB_PATCHS_QUALITE, random_state=42).copy()

# Extraction des métriques
tqdm.pandas(desc="Scan de qualité")
resultats = df_qualite['path'].progress_apply(lambda x: pd.Series(analyser_metriques_qualite(x)))
df_qualite = pd.concat([df_qualite, resultats], axis=1)
Analyse de la qualité sur un échantillon de 5000 patchs...
Scan de qualité: 100%|██████████| 5000/5000 [00:09<00:00, 507.42it/s]
In [25]:
# DÉTECTION DES ANOMALIES 

# Seuils empiriques pour la pathologie numérique
SEUIL_FLOU = 100
SEUIL_LUM_VIDE = 210  # Proche du blanc pur (255)
SEUIL_TEXT_VIDE = 15   # Très peu de variation de gris

df_qualite['est_flou'] = df_qualite['score_nettete'] < SEUIL_FLOU
df_qualite['est_vide'] = (df_qualite['intensite_moyenne'] > SEUIL_LUM_VIDE) & (df_qualite['stabilite_texture'] < SEUIL_TEXT_VIDE)
In [26]:
# VISUALISATION DES RÉSULTATS

# Création d'un dashboard de qualité à deux graphiques
fig_dash = make_subplots(
    rows=1, cols=2, 
    subplot_titles=("Distribution de la Netteté", "Détection des Zones Vides (Background)")
)

# Graphique 1 : Histogramme de la netteté
hist_nettete = px.histogram(df_qualite, x="score_nettete", log_y=True)
fig_dash.add_trace(hist_nettete.data[0], row=1, col=1)
fig_dash.add_vline(x=SEUIL_FLOU, line_dash="dash", line_color="red", row=1, col=1, annotation_text="Seuil Flou")

# Graphique 2 : Nuage de points pour le fond blanc
scatter_vide = px.scatter(
    df_qualite, x="intensite_moyenne", y="stabilite_texture", 
    color="est_vide", color_discrete_map={True: 'red', False: 'blue'},
    opacity=0.5
)
for trace in scatter_vide.data:
    fig_dash.add_trace(trace, row=1, col=2)

fig_dash.update_layout(height=500, title_text="Audit de Qualité Technique du Dataset", showlegend=False)
fig_dash.show()
In [27]:
#RÉSUMÉ STATISTIQUE & INTERPRÉTATION

taux_flou = df_qualite['est_flou'].mean() * 100
taux_vide = df_qualite['est_vide'].mean() * 100

print(f"BILAN DE SANTÉ DU DATASET")
print(f"Patchs de bonne qualité : {100 - (taux_flou + taux_vide):.2f}%")
print(f"Patchs flous détectés    : {taux_flou:.2f}% (Seuil < {SEUIL_FLOU})")
print(f"Patchs vides (fond)      : {taux_vide:.2f}% (Fond blanc sans tissu)")
BILAN DE SANTÉ DU DATASET
Patchs de bonne qualité : 99.54%
Patchs flous détectés    : 0.32% (Seuil < 100)
Patchs vides (fond)      : 0.14% (Fond blanc sans tissu)

DÉFINITIONS

  1. Variance du Laplacien : Mesure mathématique des contrastes locaux. Une image nette présente des variations brusques de gris (score élevé), tandis qu'une image floue est "lisse" (score bas).
  2. Intensité Moyenne : Si elle est proche de 255, le patch contient majoritairement du blanc (support de la lame).
  3. Stabilité de Texture (Écart-type) : Un écart-type très bas indique une image uniforme (absence de cellules).

Visualisation Qualitative

Affichons des patchs normaux et tumoraux pour comparaison visuelle directe.

In [28]:
def plot_patch_grid(label=0, n=6):
    """
    Affiche une grille de patchs avec Matplotlib en incluant les métadonnées.
    """
    sample = df[df['tumor'] == label].sample(n, random_state=42)
    
    # On augmente la taille de la figure pour que le texte soit lisible
    plt.figure(figsize=(20, 6))
    
    for i, (_, row) in enumerate(sample.iterrows()):
        # Reconstruction du chemin
        fname = f"patch_patient_{row['patient']:03d}_node_{row['node']}_x_{row['x_coord']}_y_{row['y_coord']}.png"
        path = PATCHES_DIR / f"patient_{row['patient']:03d}_node_{row['node']}" / fname
        
        if path.exists():
            img = Image.open(path)
            
            plt.subplot(1, n, i+1)
            plt.imshow(img)
            
            # Titre : Hôpital
            plt.title(f"Hôp {row['center']}", fontweight='bold')
            
            # Métadonnées : On évite le .tolist() illisible pour un format clé-valeur propre
            meta_text = (
                f"Pt: {row['patient']}\n"
                f"Node: {row['node']}\n"
                f"X: {row['x_coord']}\n"
                f"Y: {row['y_coord']}"
            )
            plt.xlabel(meta_text, fontsize=16, ha='center')
            
            # On cache les graduations mais on garde le label (xlabel)
            plt.xticks([])
            plt.yticks([])
        else:
            plt.subplot(1, n, i+1)
            plt.text(1.5, 1.5, "Image introuvable", ha='center')
            plt.axis('off')
    
    plt.suptitle(f"Exemples de patchs {'Normaux' if label==0 else 'Tumoraux'}", fontsize=20, y=1.05)
    plt.tight_layout()
    plt.show()

# --- Exécution ---
plot_patch_grid(label=0) # Normal
plot_patch_grid(label=1) # Tumeur
No description has been provided for this image
No description has been provided for this image

Synthèse de l'Analyse Visuelle

Définitions

  • Patch Normal : Tissu sain présentant une architecture organisée (cellules adipeuses ou conjonctives).
  • Patch Tumoral : Présence de cellules cancéreuses caractérisées par des noyaux volumineux, hyperchromatiques et une désorganisation tissulaire.

Observations Générales

  1. Variabilité Chromatique : Les différences de teintes selon l'hôpital confirment le Domain Shift observé en PCA.
  2. Complexité : L'apparence des tumeurs varie de formes denses à des infiltrations plus diffuses.
  3. Qualité : La résolution est suffisante pour distinguer les structures morphologiques fines.

Conclusion Le modèle devra se focaliser sur la morphologie (forme et densité des noyaux) plutôt que sur les couleurs, trop dépendantes des protocoles de numérisation des différents centres.

Analyse biais spaciale

In [29]:
import plotly.express as px

fig = px.density_heatmap(
    df,
    x='x_coord',
    y='y_coord',
    nbinsx=40,
    nbinsy=40,
    color_continuous_scale='Greens',
    title="Densité spatiale des patches",
    labels={'color': 'Densité'}
)
fig.show()

BIAIS SPATIALE :

  • Observation : Les patches tumoraux forment 4 amas localisés (vert foncé : >25 tumeurs/hexagone) au lieu d'être uniformément répartis.
  • Problème : CNN risque d'apprendre "position géographique = tumeur" au lieu des features histologiques (noyaux anormaux).
In [30]:
# 3 solutions testées
def spatial_uniformity(df):
    df = df.copy()
    df['grid_x'] = pd.cut(df['x_coord'], bins=10, labels=False)
    df['grid_y'] = pd.cut(df['y_coord'], bins=10, labels=False)
    density = df.groupby(['grid_x', 'grid_y'])['tumor'].mean()
    return density.var()
In [31]:
#Test 1
def v1_4x4(df):
    df = df.copy()
    df['grid_x'] = pd.cut(df['x_coord'], bins=4, labels=False)
    df['grid_y'] = pd.cut(df['y_coord'], bins=4, labels=False)
    subsample = []
    for center in df['center'].unique():
        for tumor in [0, 1]:
            df_target = df[(df['center'] == center) & (df['tumor'] == tumor)]
            for gx in range(4):
                for gy in range(4):
                    zone = df_target[(df_target['grid_x'] == gx) & (df_target['grid_y'] == gy)]
                    if len(zone) > 0: 
                        subsample.append(zone.sample(1))
    df_v1 = pd.concat(subsample).drop_duplicates()
    return df_v1.sample(min(5000, len(df_v1)), random_state=42)

df_v1 = v1_4x4(df)
In [32]:
#Test 2
def v2_8x8(df):
    df = df.copy()
    df['grid_x'] = pd.cut(df['x_coord'], bins=8, labels=False)
    df['grid_y'] = pd.cut(df['y_coord'], bins=8, labels=False)
    subsample = []
    for center in df['center'].unique():
        for tumor in [0, 1]:
            df_target = df[(df['center'] == center) & (df['tumor'] == tumor)]
            for gx in range(8):
                for gy in range(8):
                    zone = df_target[(df_target['grid_x'] == gx) & (df_target['grid_y'] == gy)]
                    if len(zone) > 0: 
                        subsample.append(zone.sample(1))
    df_v2 = pd.concat(subsample).drop_duplicates()
    return df_v2.sample(min(5000, len(df_v2)), random_state=42)

df_v2 = v2_8x8(df)
In [33]:
#Test 3
def v3_10x10(df):
    df = df.copy()
    df['grid_x'] = pd.cut(df['x_coord'], bins=10, labels=False)
    df['grid_y'] = pd.cut(df['y_coord'], bins=10, labels=False)
    subsample = []
    for center in df['center'].unique():
        for tumor in [0, 1]:
            df_target = df[(df['center'] == center) & (df['tumor'] == tumor)]
            for gx in range(10):
                for gy in range(10):
                    zone = df_target[(df_target['grid_x'] == gx) & (df_target['grid_y'] == gy)]
                    if len(zone) > 0: 
                        subsample.append(zone.sample(1))
    df_v3 = pd.concat(subsample).drop_duplicates()
    return df_v3.sample(min(5000, len(df_v3)), random_state=42)

df_v3 = v3_10x10(df)
In [34]:
#Comparaison
results = pd.DataFrame({
    'Version': ['AVANT', 'V1 4x4', 'V2 8x8', 'V3 10x10'],
    'N patches': [len(df), len(df_v1), len(df_v2), len(df_v3)],
    'Balance %': [df['tumor'].mean(), df_v1['tumor'].mean(), df_v2['tumor'].mean(), df_v3['tumor'].mean()],
    'Variance spatiale': [
        spatial_uniformity(df), spatial_uniformity(df_v1), 
        spatial_uniformity(df_v2), spatial_uniformity(df_v3)
    ]
})

print("Classement")
print(results.round(3).to_markdown(index=False))

best_idx = results['Variance spatiale'].idxmin()
best_version = results.iloc[best_idx]
print(f"\n MEILLEURE : {best_version['Version']} (variance {best_version['Variance spatiale']:.3f})")
Classement
| Version   |   N patches |   Balance % |   Variance spatiale |
|:----------|------------:|------------:|--------------------:|
| AVANT     |      455954 |       0.5   |               0.097 |
| V1 4x4    |          88 |       0.398 |               0.193 |
| V2 8x8    |         265 |       0.336 |               0.085 |
| V3 10x10  |         364 |       0.291 |               0.056 |

 MEILLEURE : V3 10x10 (variance 0.056)
In [35]:
#Visualisation
datasets = {'AVANT': df, 'V1 4x4': df_v1, 'V2 8x8': df_v2, 'V3 10x10': df_v3}
df_all = pd.concat([d.assign(version=name) for name, d in datasets.items()])

fig = px.density_heatmap(
    df_all, x='x_coord', y='y_coord', z='tumor',
    facet_col='version', facet_col_wrap=2,
    histfunc='avg', nbinsx=25, nbinsy=25,
    color_continuous_scale='RdYlGn',
    title='CORRECTION BIAS SPATIAL V1-V2-V3',
    category_orders={'version': list(datasets.keys())},
    height=800, width=1000
)

fig.update_layout(coloraxis_colorbar=dict(title='Tumor (avg)'))
fig.show()
In [36]:
#Sauvegarder la meilleure
if best_version['Version'] == 'V2 8x8': df_final = df_v2
elif best_version['Version'] == 'V3 10x10': df_final = df_v3
else: df_final = df_v1

df_final.to_csv('../data/processed/df_final.csv', index=False)
print(f"Meilleure version sauvée : {len(df_final)} patches")
Meilleure version sauvée : 364 patches

Résumé Exécutif

Catégorie Résultat Observation
Volume 455,954 patchs Dataset massif et complet.
Structure 43 patients / 5 hôpitaux Répartition géographique claire (1 hôpital par patient).
Équilibre 50% / 50% Dataset parfaitement équilibré entre Normal et Tumeur par hôpital.
Domain Shift Significatif Différences de luminosité et de balance des couleurs entre hôpitaux (Center 4 vs Center 1).
Qualité Excellente Très peu de patchs flous ou vides (<0.32%).